Skip to content

[ty] Add initial implementation of goto definition for loads of local names #19123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

Gankra
Copy link
Contributor

@Gankra Gankra commented Jul 3, 2025

No description provided.

@Gankra Gankra added server Related to the LSP server ty Multi-file analysis & type inference labels Jul 3, 2025
Comment on lines 164 to 175
let index = semantic_index(model.db, model.file);
let file_scope = index.expression_scope_id(*self);
let scope = file_scope.to_scope_id(model.db, model.file);
let use_def = index.use_def_map(file_scope);
let use_id = self.scoped_use_id(model.db, scope);

Some(
use_def
.bindings_at_use(use_id)
.filter_map(|binding| binding.binding.definition())
.collect(),
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is basically the happy path of TypeInferenceBuilder::infer_place_load. It's extremely limited because it can't walk into parent scopes, and because it never engages type inference, it can't reason about "oh this is MyClass.myfield".

I feel like the more Correct solution we want here is to augment infer_place_load to also yield Definition info, and then run the whole TypeInferenceBuilder machinery just like inferred_type does.

@@ -2667,7 +2667,7 @@ impl<'db> BindingError<'db> {
if let Some(builder) = context.report_lint(&MISSING_ARGUMENT, node) {
let s = if parameters.0.len() == 1 { "" } else { "s" };
let mut diag = builder.into_diagnostic(format_args!(
"No argument{s} provided for required parameter{s} {parameters}{}",
"No argument{s} provided for required parameterzz{s} {parameters}{}",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops.

Comment on lines +15 to +19
pub(crate) struct GotoDefinitionRequestHandler;

impl RequestHandler for GotoDefinitionRequestHandler {
type RequestType = GotoDefinition;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything in this file is a copy of goto_type_definition but changed to goto_definition

Comment on lines +938 to +946
assert_snapshot!(test.goto_definition(), @r"
info[goto-type-definition]: Type definition
--> main.py:4:13
|
2 | ab = 1
3 | ab = 2
4 | ab = 3
| ^^
5 | print(ab)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first non-trivial property of the current implementation: the "definitions" we yield are the bindings that dominate the load. This is a useful answer but obviously not the only possible one.

Comment on lines +991 to +995
2 | ab = 1
3 | if cond:
4 | ab = 2
| ^^
5 | print(ab)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(in this case we point to both ab = 1 (above) and ab = 2 as both bindings can affect the load)

Comment on lines +1063 to +1068
ab: int
print(a<CURSOR>b)
"#,
);

assert_snapshot!(test.goto_definition(), @"No definitions found");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case there's no bindings for ab so we fail to yield anything (suboptimal).

Copy link
Member

@dhruvmanila dhruvmanila Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +1186 to +1191
a<CURSOR>b += 2
print(ab)
"#,
);

assert_snapshot!(test.goto_definition(), @"No goto target found");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any kind of Store completely falls over right now -- it seems like most of the code gives up when it sees Store, even if it's also a Load, but maybe it's handled by different paths? (in the playground ab += 2 does seem to support getting the type of ab)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, augmented assignments are both LOAD and STORE.

Comment on lines 1209 to 1221
2 | class AB(val: int):
| ^^
3 | self.myval = val
|
info: Source
--> main.py:5:17
|
3 | self.myval = val
4 |
5 | x = AB(5)
| ^^
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this is only working because the class is defined in the same scope. If x = AB(5) was in a function it would fail to resolve, because we don't look into parent scopes. yet.

Comment on lines +1231 to +1256
x = AB(5)
print(x.my<CURSOR>val)
"#,
);

assert_snapshot!(test.goto_definition(), @"No goto target found");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is hopeless without type info.

RED = "red"
BLUE = "blue"

x = AB.RE<CURSOR>D
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly hopeless without type info

Comment on lines +1335 to +1363
ab = 1
def myfunc():
global ab
print(a<CURSOR>b)
"#,
);

assert_snapshot!(test.goto_definition(), @"No definitions found");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Globals of course fail here without processing of parent scopes.

Copy link
Contributor

github-actions bot commented Jul 3, 2025

mypy_primer results

No ecosystem changes detected ✅
No memory usage changes detected ✅

Copy link
Member

@dhruvmanila dhruvmanila left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this on! This seems like a good start but I'm a bit hesitant on shipping this based on the limitation that this only works if both the definition and usage are in the same scope.

The reason this limitation exists for goto definition and not goto type definition is because the former is only looking at the scope of the expression where it's being used while the latter is performing the type inference and fetching the list of definitions from Type instead (Type::definition). I think you're most likely aware of this already.

The reason it's fine for goto type definition is because we actually need the type definition which would either be in the stub file (if present) or the runtime file. This is the behavior of type inference in ty so it falls out naturally in the LSP.

For goto definition, I think we'll need something else. One way to solve this would be the following which is a bit high level:

  1. This is something that Eric mentioned -- create a source mapping between the definitions in runtime and stub file
  2. Use the same path as goto type definition but use the above mapping to get the corresponding runtime definition instead of the type definition

But to decrease the scope, we could probably ship a limited version which performs the same steps as goto type definition but filters out all the targets that would lead to a stub file and only return the ones that are present in the runtime files. This does mean that goto definition on any builtin symbols or the ones originating in the standard library wouldn't work because they all go to the stub files present in typeshed.

Comment on lines +55 to +57
if snapshot
.resolved_client_capabilities()
.type_definition_link_support
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the linkSupport value from the DefinitionClientCapabilities instead: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition

You'll need to add a new definition_link_support field in ResolvedClientCapabilities which takes it's value from document.definition.link_support. Refer to how type_definition_link_support is being computed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm what distinguishes this from goto_type_definition, which has the same impl? (or is this a "also fix goto_type_definition"?)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the client capabilities, so if a client has link support for goto type definition but not for goto definition (for whatever reason), the server needs to respect that. These capabilities are provided to the server during initialization where we make note of all the things that the client supports through which the server needs to make certain decisions. This is one of them.

)
}
ExprContext::Store => None,
ExprContext::Del => None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can include ExprContext::Del here as the following should go to the definition of x:

def _():
    x = 1
    del x<CURSOR>

.collect(),
)
}
ExprContext::Store => None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might a bit tricky because the definition that corresponds to this expression itself should also be included in the list of definitions. For example, here both definitions should be included:

def _():
    x = 1
    x<CURSOR> = 2

Comment on lines +1186 to +1191
a<CURSOR>b += 2
print(ab)
"#,
);

assert_snapshot!(test.goto_definition(), @"No goto target found");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, augmented assignments are both LOAD and STORE.

Comment on lines +192 to +205
ExprContext::Load => {
let index = semantic_index(model.db, model.file);
let file_scope = index.expression_scope_id(*self);
let scope = file_scope.to_scope_id(model.db, model.file);
let use_def = index.use_def_map(file_scope);
let use_id = self.scoped_use_id(model.db, scope);

Some(
use_def
.bindings_at_use(use_id)
.filter_map(|binding| binding.binding.definition())
.collect(),
)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh hmm, I see what you mean based on what you mentioned on Discord. So, this implementation would be limited to only provide definitions if they're defined in the current scope, right? The following doesn't work:

x = 1


def _():
    x<CURSOR>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes correct.


Some(RangedValue {
range: FileRange::new(file, goto_target.range()),
value: NavigationTargets::unique(targets),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we require to call unique here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For whatever reason it's the constructor that takes a list (the name was concerning, but, it works?)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea behind using unique here is that, at least for go to type definition, we want to avoid showing the same definition twice. E.g. if you have a Class<str> | Class<int> type. We only want to list one target because both those type point to the generic Class<T>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I think this makes sense for goto definitions as well.

@@ -29,6 +30,35 @@ pub fn goto_type_definition(
})
}

pub fn goto_definition(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should split this into two files: goto_type_definition.rs and goto_definition.rs to isolate the logic and the tests. I'm not exactly sure where should the shared logic reside in, maybe the goto.rs could be used for that but that sounds a bit confusing.

Comment on lines +1411 to +1416
let source = targets.range;
self.render_diagnostics(
targets
.into_iter()
.map(|target| GotoTypeDefinitionDiagnostic::new(source, &target)),
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to create a generic GotoDiagnostic which takes in an enum that specifies which kind it is: GotoKind::Definition, GotoKind::TypeDefinition, GotoKind::Declaration, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
server Related to the LSP server ty Multi-file analysis & type inference
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants